SD37. 경쟁 조건: 검사시점과 사용시점(TOCTOU) — 파일 디스크립터 원자적 생성으로 치환
원본 항목: Part 3. 시간 및 상태 — 1. 경쟁 조건: 검사시점과 사용시점(TOCTOU)CWE-367repo 폴더: sd37_toctou/ (빌드·실행은 §3-1)
목표 시간: 1회차 15분 / 2회차 9분 / 3회차 6분
Part 3의 첫 드릴이자 이 계열은 실제 타이밍 경쟁(멀티스레드 레이스)을 재현하지 않는다 — 대신 **“이름이 검사 시점 이후 다른 대상을 가리키면 어떻게 되는가”**를 심볼릭 링크로 결정론적으로 재현한다.
실제 공격도 결국 이 구조를 이용한다.
이 드릴의 문서 구성 (SD01~SD58 공통)
① 요구사항서 — 어떤 코드가 뚫려 있고, 무엇을 만드나.
② pseudo code 템플릿 — Bad 와 Good 의 뼈대이고, 회차마다 가린다.
③ 테스트 코드 — 공격은 막히고 정상은 통과해야 끝이다.
④ 자주 하는 실수
⑤ 모범답안 — 맨 뒤에 접어 둔다.
변형 A/B/C 는 같은 치환 규칙을 다른 소재로 다시 치는 것이다.
0. 이 드릴로 체화할 것
- if (!access(file, W_OK)) /* 검사 시점(TOC) — "이름"으로 검사 */- f = fopen(file, "w+"); /* 사용 시점(TOU) — 같은 "이름"을 다시 연다 */+ int fd = open(file_name, O_WRONLY|O_CREAT|O_EXCL, S_IRWXU); /* 생성과 검사가 한 시스템 콜 안에서 원자적 */+ if (fd == -1) { /* 이미 존재하면(심볼릭 링크 포함) 무조건 실패 */ }
이름으로 검사하고 이름으로 다시 여는 것 자체가 결함이다.
그 사이에 이름이 가리키는 대상이 바뀔 수 있기 때문이다.
O_CREAT|O_EXCL은 “이미 뭔가 있으면 절대 열지 않는다”를 커널이 원자적으로 보장한다.
회차별 진행
회차
변형
보는 것
순서
1회차
A 업로드 임시 파일 생성
① 요구사항서 ② pseudo code 전체
1. Bad 를 그대로 쳐서 취약점을 관찰한다. 2. Good pseudo 를 한 줄씩 코드로 바꾼다. 3. ③ 테스트를 실행한다.
2회차
B 잔액 차감 로직으로 변형
① 요구사항서 ② pseudo code 의 번호와 제목만 (Level 2)
1. Bad 를 치고 취약점을 확인한다. 2. 번호만 보고 Good 을 쓴다. 3. ③ 테스트를 실행한다.
3회차
C 디렉터리 재귀 탐색 중 심볼릭 링크 차단
① 요구사항서만
1. ③ 테스트를 먼저 읽는다. 2. 백지에서 Bad 부터 Good 까지 재현한다.
1회차에 막히면 같은 변형을 한 번 더 친다.
3회차를 통과하면 A 를 백지로 다시 친다.
int file_operation_good(const char *path, const char *content){ /* 아무것도 안 본다. * §1 요구사항서와 §0 의 치환 규칙만 보고 친다. * * 막히면 아래만 찾아본다. * - `O_EXCL` 은 `O_CREAT` 와 같이 쓴다 * - `fdopen` 이 실패하면 fd 는 호출자가 닫는다 * - 검사 로직을 강화하는 게 아니라 **틈을 없앤다** */}
막혔을 때 찾는 순서
컴파일 에러가 나면 에러 문구를 본다.
unknown type name / implicit declaration 이면 include 누락이다.
man 3 <함수> 를 연다.
SYNOPSIS 맨 위의 #include 를 확인한다.
로직이 막히면 §3 테스트의 기대값을 먼저 읽는다.
공격 입력표(§1)와 테스트가 답이다.
#define _DEFAULT_SOURCE#include <stdio.h>#include <string.h>#include <stdlib.h>#include <unistd.h>#include <limits.h>#include "toctou.h"static int g_fail = 0;#define T_TRUE(cond, msg) \ do { if (!(cond)) { g_fail++; \ printf(" X %s:%d %s\n", __func__, __LINE__, msg); } } while (0)static void write_file(const char *path, const char *content){ FILE *f = fopen(path, "w"); if (f) { fputs(content, f); fclose(f); }}static int read_file(const char *path, char *buf, size_t n){ FILE *f = fopen(path, "r"); size_t r; if (!f) return -1; r = fread(buf, 1, n - 1, f); buf[r] = '\0'; fclose(f); return 0;}int main(void){ char base[] = "/tmp/sd37_XXXXXX"; char target[PATH_MAX], link[PATH_MAX], fresh[PATH_MAX]; char content[128]; if (mkdtemp(base) == NULL) { perror("mkdtemp"); return 1; } snprintf(target, sizeof(target), "%s/victim_target", base); snprintf(link, sizeof(link), "%s/toctou_link", base); snprintf(fresh, sizeof(fresh), "%s/fresh_report", base); write_file(target, "ORIGINAL"); symlink(target, link); /* 검사 시점에는 정상 파일을 가리키던 이름 */ T_TRUE(file_operation_bad(link, "HACKED") == 0, "Bad는 심볼릭 링크를 따라 정상적으로 '성공'해야 한다"); read_file(target, content, sizeof(content)); T_TRUE(strcmp(content, "HACKED") == 0, "Bad는 이름만 보고 fopen 했으므로, 링크 뒤에 있던 victim_target이 그대로 덮어써져야 한다(취약점 재현)"); write_file(target, "ORIGINAL"); /* 다음 검증을 위해 복원 */ T_TRUE(file_operation_good(link, "HACKED2") == -1, "Good은 O_CREAT|O_EXCL이므로 이미 존재하는 이름(심볼릭 링크 포함)에는 실패해야 한다"); read_file(target, content, sizeof(content)); T_TRUE(strcmp(content, "ORIGINAL") == 0, "Good이 거부했다면 victim_target 내용은 전혀 바뀌지 않아야 한다"); T_TRUE(file_operation_good(fresh, "FRESH") == 0, "Good은 완전히 새 경로에는 정상적으로 성공해야 한다"); read_file(fresh, content, sizeof(content)); T_TRUE(strcmp(content, "FRESH") == 0, "새로 만든 파일에는 내용이 정확히 써져야 한다"); unlink(link); unlink(target); unlink(fresh); rmdir(base); printf(g_fail ? "FAIL %d\n" : "PASS\n", g_fail); return g_fail ? 1 : 0;}
이 테스트가 "진짜 레이스"가 아닌 이유
실제 TOCTOU 공격은 검사와 사용 사이의 아주 좁은 시간창을 노려 이름의 실체를 바꿔치기한다(타이밍 의존적).
이 드릴은 그 시간창을 인위적으로 넓혀서 — 애초에 심볼릭 링크를 미리 걸어두고 — **“이름 기반 재해석이 실제로 무엇을 따라가는가”**라는 구조적 결함만 결정론적으로 재현한다.
실전에서는 이 구조 위에 타이밍이 더해질 뿐이다.
#define _DEFAULT_SOURCE#include <fcntl.h>#include <unistd.h>#include <stdio.h>#include <sys/stat.h>#include "toctou.h"int file_operation_bad(const char *path, const char *content){ FILE *f; if (access(path, W_OK) != 0) return -1; /* 검사 시점(TOC) */ f = fopen(path, "w+"); /* 사용 시점(TOU) -- 그 사이 이름이 다른 대상을 가리켜도 그대로 따라감 */ if (!f) return -1; fputs(content, f); fclose(f); return 0;}int file_operation_good(const char *path, const char *content){ int fd = open(path, O_WRONLY | O_CREAT | O_EXCL, S_IRUSR | S_IWUSR); /* 생성.검사 원자적 */ FILE *f; if (fd == -1) return -1; if (fchmod(fd, S_IRUSR | S_IWUSR) == -1) { close(fd); return -1; } f = fdopen(fd, "w"); if (!f) { close(fd); return -1; } fputs(content, f); fclose(f); return 0;}
눈여겨볼 점: file_operation_bad는 시스템 콜을 두 번(access → fopen) 나눠 부른다.
그 사이가 곧 경쟁 구간이다.
file_operation_good은 생성·존재확인을 open()한 번으로 합쳐서, “그 사이”라는 시간 자체를 없애버린다.
TOCTOU 방어의 본질은 검사 로직 강화가 아니라 검사와 사용 사이의 틈을 시스템 콜 차원에서 없애는 것이다.